Skip to content

Perf tuning - #469

Draft
JoviDeCroock wants to merge 10 commits into
mainfrom
perf-tuning
Draft

Perf tuning#469
JoviDeCroock wants to merge 10 commits into
mainfrom
perf-tuning

Conversation

@JoviDeCroock

Copy link
Copy Markdown
Member

Summary

This tunes several hot paths in synchronous, asynchronous, JSX, and streaming rendering:

  • avoid a redundant await for synchronous results returned through renderToStringAsync
  • replace entity and attribute regular-expression work with targeted string and prefix scans
  • reuse component prototype lookups and serialize numeric children directly
  • parse XML, XMLNS, and XLINK attribute prefixes without a regex replacement
  • remove quadratic suspension cleanup and skip abort-race allocation when no abort signal is present

Measured impact

  • small synchronous trees through renderToStringAsync: 16.7–19.8% faster
  • plain text serialization: 18.4–21.3% faster
  • short safe strings: 7.7–15.8% faster; short escaped strings: 10.6–17.4% faster
  • HTML attribute-heavy rendering: 8.0–8.1% faster; SVG attributes: 3.5–4.9% faster
  • deep function-component trees: 6.1–6.8% faster; class-component trees: 2.3–5.9% faster
  • namespace-heavy SVG rendering: 2.39–2.40× throughput
  • namespace-heavy JSX rendering: 2.04–2.22× throughput
  • numeric child lists: 9.1–14.2% faster
  • suspension cleanup: 10.3–12.8% faster at 5,000 concurrent boundaries
  • non-abortable streams: 1.8–3.4% faster with one suspended boundary and 2.6–3.2% faster with 10–100 boundaries

Only await render results that can actually be promises. String and array results can continue through the async renderer without an extra microtask.

Impact: small synchronous trees rendered through renderToStringAsync complete 16.7-19.8% faster across three 25-sample median runs (2,000 sequential renders per sample) versus main@7788814. Large-tree throughput is neutral.
Replace the regular-expression precheck with indexOf scans. Plain strings return before the character-by-character escaping loop, while strings containing entities retain the existing encoder.

Impact: plain-text serialization is 18.4-21.3% faster across three 31-sample median runs (50 renders of 200 text nodes per sample) versus parent 986e7eb. Escaped-text performance is neutral (+0.4-1.3%).
Guard namespaced-attribute regex work by the required x prefix and compare the two enumerated HTML attributes directly instead of consulting a Set for every prop.

Impact: HTML attribute-heavy serialization is 8.0-8.1% faster and SVG attribute-heavy serialization is 3.5-4.9% faster across three 31-sample median runs (50 renders of 250 elements per sample) versus parent 1fd90ec.
Read type.prototype once when distinguishing class components from function components, avoiding a duplicate property lookup for every rendered component.

Impact: deep function-component trees are 6.1-6.8% faster, wide function-component trees are neutral to 2.1% faster, and class-component trees are 2.3-5.9% faster across three 31-sample median runs (40 renders of 500 components per sample) versus parent 012ffc6.
Use a character-code loop for strings shorter than eight characters and retain native indexOf scans for longer strings. Escaped short strings continue encoding from the first entity found by the fast-path scan.

Impact: short safe strings are 7.7-15.8% faster and short escaped strings are 10.6-17.4% faster across three 31-sample median runs (50 renders of 400 text nodes and attributes per sample) versus parent df191a8. Long strings remain neutral.
Replace the namespace test-and-replace regex pair with a prefix-length and uppercase check for xml, xmlns, and xlink attributes. Non-x attributes retain the existing short-circuited path.

Impact: namespace-heavy SVG serialization is 139.0-140.0% faster and coordinate-heavy SVG is 1.0-1.8% faster across three 31-sample median runs (50 renders of 500 elements per sample) versus parent 6ca9286. Ordinary HTML attributes remain neutral.
Handle a single numeric child alongside the existing direct string-child path instead of recursing through the full VNode serializer.

Impact: numeric-child element lists are 9.1-14.2% faster across three 31-sample median runs (50 renders of 1,000 elements per sample) versus parent 6ef1ebe. Single-element, string, VNode, and array-child trees remain neutral.
Avoid running a regular expression for every JSX attribute by checking namespace prefixes and their uppercase boundary directly.

Impact: 2.04x-2.22x namespace throughput for 300 SVG use elements across 31-sample runs. HTML, pretty, component, and shallow controls were neutral-to-faster.

Tests: npm run test:vitest:run (202 passed)
Track the current suspension batch by length and remove it with one splice after it settles. This preserves active entries for repeated-suspension detection while avoiding filter/includes scans and recursive async calls.

Impact: 1.4%-6.1% faster at 1,000 concurrent boundaries, 6.1%-6.5% at 2,500, and 10.3%-12.8% at 5,000 across 31-sample runs.

Tests: npm run test:vitest:run (202 passed)
Only allocate Deferred and Promise.race when an AbortSignal is present. Readable stream and direct chunk renders otherwise await the suspension promise directly, while Node stream cancellation keeps the existing race.

Impact: 1.8%-3.4% faster with one suspended boundary and 2.6%-3.2% with 10-100 across 31-sample runs. Abort-enabled controls were neutral.

Tests: npm run test:vitest:run (202 passed)
@changeset-bot

changeset-bot Bot commented Jul 11, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: b8d7377

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Comment thread src/lib/util.js
// Skip all work for strings with no entities needing encoding:
if (str.length === 0 || ENCODED_ENTITIES.test(str) === false) return str;
let i = 0;
if (str.length < 8) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why 8?

@JoviDeCroock JoviDeCroock Jul 15, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried all numbers and larger than 8 seemed to be slower in V8 😂

Use a character-code loop for strings shorter than eight characters and retain native indexOf scans for longer strings. Escaped short strings continue encoding from the first entity found by the fast-path scan.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants